#!/usr/bin/python

'''
NAME:           plumbing.py
AUTHOR:         Shane Graber, sgraber@gmail.com
DATE:           2007-04-14
COPYRIGHT:      Copyright 2007 Shane Graber <sgraber@gmail.com>
REPOSITORY:     http://code.google.com/p/plumbingpy/

DESCRIPTION:
GUI application that calculates plumbing frictional loss from a given 
pump/plumbing configuration.

BACKGROUND:
This is a derivative work that utilizes certain functions from Tanklogger* 
by Jason Nugent.  Converted head loss PHP module from TankLogger (which makes
use of XML configuration files) to python executable that uses a GUI for user
interaction.  Reused many of the math functions, pumps.xml, and frictions.xml 
configuration files with changes made where needed.

* TankLogger: http://sourceforge.net/projects/tanklogger

DEPENDENCIES: 
Python 2.4
BeautifulSoup (http://www.crummy.com/software/BeautifulSoup/)

LICENSE:
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2, or (at your option)
any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software Foundation,
Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
'''

__author__ = "Shane Graber <sgraber@gmail.com>"
__version__ = "$Rev$"
__date__ = "$Date$"
__copyright__ = "Copyright 2007 Shane Graber"

from Tix import *
from Tkconstants import *
from math import *
from sys import exit
from csv import writer
import tkFileDialog, tkMessageBox
import os
import textwrap

# to parse our XML files (pumps.xml, frictions.xml, pipes.xml)
try:
    from BeautifulSoup import BeautifulStoneSoup
except ImportError:
    print 'BeautifulSoup not installed.  Please download from http://www.crummy.com/software/BeautifulSoup/ and install.'
    exit()

def open_file(filename):
    ''' Open our XML files. '''
    pathname = os.path.abspath(os.path.dirname(sys.argv[0]))  # this is how we get the path to the script's directory
    filename = os.path.join(pathname, filename) 
    soup = []
    if os.path.exists(filename):
        file = open(filename, 'r')
        soup = BeautifulStoneSoup(file)
        file.close()
    else:
        config_problems(filename)
    return soup

def save_file():
    ''' Save the file in .csv format. '''
    formats = [ ('Comma Delimited', '*.csv') ]
    filename = tkFileDialog.asksaveasfilename(parent=win, filetypes=formats, title="Save...")
    if len(filename) > 0:
        if '.csv' not in filename:
            filename = filename + '.csv'
        file = writer(open(filename, 'wb'))
        data = [
            ('Pump Head Loss Calculation',),
            (),
            ('Plumbing Data',),
            ('Pump', selected_pump_field.get() ), 
            ('Pipe Diameter', selected_diameter_field.get(), 'inches'), 
            ('Vertical Height', height_field.get(), 'feet'), 
            ('Horizontal Length', length_field.get(), 'feet'), 
            ('Number of 90 Degree Elbows', elbows90_field.get() ), 
            ('Number of 45 Degree Elbows', elbows45_field.get() ),
            ('Number of Gate Valves', gate_valves_field.get() ) ,
            ('Number of Ball Valves', ball_valves_field.get() ), 
            ('Number of Union Couplings', unions_field.get() ), 
            ('Number of Swing Check Valves', check_valves_field.get() ), 
            ('Number of Pipe Exits', exits_field.get() ), 
            ('Number of Pipe Entrances', entrances_field.get() ), 
            (),
            ('Results',),
            ('Head Pressure', ft_loss_field.get(), 'ft loss'), 
            ('Head Pressure', psi_field.get(), 'PSI'), 
            ('Flow Rate', gph_field.get(), 'GPH'),
            ('Exit Velocity', velocity_field.get(), 'ft/sec', '(per exit)'),             
            ] 
        for line in data:
             file.writerow(line)
    return

def pumps_list():
    '''  All the pumps used in this program.  '''   
    pumps = []
    allpumps = pumpsoup.findAll('pump')
    for pump in allpumps:
        pumps.append(pump['name'])        
    return pumps
    
def reset_fields():
    ''' Set initial values for all fields. '''
    height_field.set('0')
    length_field.set('0')
    elbows90_field.set('0')
    elbows45_field.set('0')
    gate_valves_field.set('0')
    ball_valves_field.set('0')
    unions_field.set('0')
    check_valves_field.set('0')
    exits_field.set('1')
    entrances_field.set('1')
    ft_loss_field.set('')
    psi_field.set('')
    gph_field.set('')
    velocity_field.set('')

# SOLVE THE PROBLEM #

def solution():
    ''' Solve our problem.  This is the main loop. '''
    try:
        # get our pump and pump constants
        pump = selected_pump_field.get()
        a = float(pumpsoup.findAll('pump', {'name':pump})[0].a.string)
        b = float(pumpsoup.findAll('pump', {'name':pump})[0].b.string)
        c = float(pumpsoup.findAll('pump', {'name':pump})[0].c.string)
        d = float(pumpsoup.findAll('pump', {'name':pump})[0].d.string)
        
        # get our pipe length and diameter
        vlength = float(height_field.get())
        hlength = float(length_field.get())
        total_pipe = vlength + hlength
        diameter = float(selected_diameter_field.get())
        real_diameter = get_inner_diameter(diameter) # gets the real inner diameter
        
        flow_rate = 10  # assume 10 gpm to start. we have to start somewhere.  :P
        
        items = items_tracked(diameter)  # create our items to track dictionary: fitting names, number of each, and their respective k-factors
        
        friction_losses = calculate_friction_loss(total_pipe, flow_rate, real_diameter)
        fitting_losses = calculate_friction_plumbing_losses(items, real_diameter, flow_rate)
        
        loss = friction_losses + fitting_losses + vlength
        
        count = 0
        compare_result = compare_gpm(flow_rate, loss, a, b, c, d)
                
        while ( (compare_result != 0) and (count <= 2000) ):    # changed count<=349 to count<=2000 as large pumps w/ low head pressure exit the iteration loop too quickly
            
            count = count + 1
            
            if compare_result > 0 and compare_result != flow_rate:  # odd case when flow rate guess was so high that the calculated flow based on calculated friction losses was zero
                if compare_result > 1:
                    flow_rate = flow_rate - 0.1
                else:                               # we are w/in 1 gpm of the pump curve, reduce flow rate change
                    flow_rate = flow_rate - 0.01    # fine tuning
            elif compare_result < 0:
                if compare_result < -1:
                    flow_rate = flow_rate + 0.1
                else:                               # we are w/in 1 gpm of the pump curve, reduce flow rate change
                    flow_rate = flow_rate + 0.01    # fine tuning
            else:
                flow_rate = flow_rate * 0.66
            
            friction_losses = calculate_friction_loss(total_pipe, flow_rate, real_diameter)
            fitting_losses = calculate_friction_plumbing_losses(items, real_diameter, flow_rate)
            loss = friction_losses + fitting_losses + vlength
            compare_result = compare_gpm(flow_rate, loss, a, b, c, d)

        gpm = calc_gpm(loss, a, b, c, d)

        ft_loss_field.set(round(loss, 2))           # ft head loss
        psi_field.set(round(loss * 0.4321, 2))      # pounds/inch^2
        gph_field.set(round(gpm * 60, 2))           # gal/hr
        velocity_field.set(round(compute_velocity(real_diameter, gpm) / float(exits_field.get()), 2))     # ft/sec/exit
        
    except ValueError:
        entry_error()
    return 

# HERE'S ALL OF OUR SOLUTION FUNCTIONS 

def items_tracked(dia):
    ''' Return a dictionary of the items, number of each, and friction coeff (k) for each. '''
    
    items = {'90 degree bend'   :   [ float( elbows90_field.get()      )   ],
             '45 degree bend'   :   [ float( elbows45_field.get()      )   ],
             'ball valve'       :   [ float( ball_valves_field.get()    )   ],
             'gate valve'       :   [ float( gate_valves_field.get()    )   ],
             'pipe coupling'    :   [ float( unions_field.get()        )   ],
             'check valve'      :   [ float( check_valves_field.get()   )   ],
             'pipe exit'        :   [ float( exits_field.get()         )   ],
             'pipe entrance'    :   [ float( entrances_field.get()     )   ],
             }
             
    for item in items.keys():
        fitting = frictionsoup.findAll('item', {'name':item})[0].findAll('dia', size=dia)[0].string
        k = float(fitting)
        items[item].append(k)
    return items

def calculate_friction_loss(total, flow, dia):
    '''
    Returns the friction loss (in feet) based on the Darcy-Wiesenbach formula.  
    Accepts the total length of pipe in feet, the flow rate in gallons per 
    minute, and the diameter of the pipe in inches.
    '''
    reynolds_number = compute_reynolds_number(dia, flow)
    friction_factor = compute_friction_factor(reynolds_number, dia)
    velocity = compute_velocity(dia, flow)
    friction_loss = friction_factor * ((total / (dia / 12)) * ((velocity * velocity) / (2 * 32.2)))
    return friction_loss
    
def calculate_friction_plumbing_losses(items, dia, flow_rate):
    '''
    Convenience function for a point of entry into calculating frictions for items.
    Accepts a dctionary of part names, # components, k-factors, flow rate, and diameter.
    '''
    head_loss = 0
    for id in items.keys():
        number = items[id][0]   # number of fittings for this item
        k = items[id][1]        # the k-factor for this fitting
        if number > 0:
            head_loss = head_loss + get_friction_for_item(k, number, dia, flow_rate)
    return head_loss

def get_friction_for_item(k, number, dia, flow_rate):
    '''
    Calculates the head loss in feet for a particular plumbing component.  
    Accepts the k-factor, the number of items in the system, the inner 
    diameter of the piece, and the flowrate in gallons  per minute.
    Uses the formula: HeadLoss = K * (Velocity in Feet/Sec ^ 2)/(2 * gravity in feet per second squared)
    '''
    velocity = compute_velocity(dia, flow_rate)
    head_loss = number * k * (velocity * velocity) / (2 * 32.2)
    return head_loss

def compare_gpm(curr_rate, loss, a, b, c, d):
    '''
    Compares a given flow rate with a calculated one and either returns 0 
    if they are "close enough", or the difference between the two.  Used 
    for determining if a given flowrate (in GPM) falls on the pump curve
    for a given pump at a given head pressure.  Called many times by the
    interative process which solves the circular equations.
    '''
    calculated_gpm = calc_gpm(loss, a, b, c, d)
    difference = curr_rate - calculated_gpm

    if abs(difference) <= 0.05:
        return 0
    else:
        return difference

def calc_gpm(loss, a, b, c, d): 
    '''
    Calculates a flow rate in gallons per minute, based on headloss and four pump coeffcients.
    Uses the formula: rate (GPM) = ax^3 + bx^2 + cx + d where x is the headloss in feet, and
    a, b, c, and d are coefficients supplied by the pump manufacturer.
    '''
    gpm = a * pow(loss, 3) + b * pow(loss, 2) + loss * c + d
    if gpm < 0:
        gpm = 0
    return gpm
    
def compute_reynolds_number(dia, flow_rate):
    '''
    Computes the Reynolds number.  Accepts the diameter in inches (which it converts to feet), 
    as well as the velocity in gallons per minute, which it converts to feet per second. 
    assumes water temperature of 80F for kinematic viscosity.
    '''
    velocity = compute_velocity(dia, flow_rate)     
    dia = dia / 12
    computed = (dia * velocity) / 0.0000090363
    return computed
    
def compute_velocity(dia, flow_rate):
    ''' Converts gallons per minute to feet per second '''
    computed_velocity = (0.4085 * flow_rate) / (dia * dia)
    return computed_velocity

def compute_friction_factor(reynolds, dia):
    ''' computes the friction factor using the Swamee-Jain approximation. '''
    lg = log(0.000005 / (3.7 * (dia / 12)) + (5.74 / pow(reynolds, 0.9))) / log(10)
    computed = 0.25 / (lg * lg)
    return computed

def get_inner_diameter(dia):
    ''' Returns the correct inner diameter of PVC. Accepts the diameter as an argument '''
    diameters = {}
    pipes = pipesoup.findAll('pipe')
    for pipe in pipes:
        id = float(pipe.string)
        od = float(pipe['od'])
        diameters[id] = od
    return diameters[dia]
    
# POPUP DIALOG WINDOWS 

def usage():
    '''POPUP WINDOW: How to use this program'''
    text = '''DIRECTIONS:\n\nSelect a pump from the "Select your Pump" menu.\nNext, enter your system data in the spaces provided. Vertical Length\nrefers only to height that you need to pump the water vertically.\nHorizontal Length refers to the total horizontal length of pipe from the\noutput of your sump to the input into your tank. If there are any valves,\nelbows, couplings, etc, enter that data into the spaces provided. Once all\nof the system data is entered into the required fields, click the SOLVE!\nbutton to find out what your overall system flow and plumbing losses will\nbe with the given pump/plumbing information.\n\nThe results will be printed out at the bottom of your screen.\n\nFor additional plumbing and pump information, please see the January 2003\nissue of Advanced Aquarist for Sanjay Joshi, Nathan Paden, and Shane\nGraber's "An Engineering View of Aquarium Systems Design: Pumps and\nPlumbing" article.\n\nFor technical pump data, please see the following URL:\nhttp://www.reefs.org/library/pumps/ '''
    tkMessageBox.showinfo("How to Use This Program", text)    

def about():
    '''POPUP WINDOW: About this program'''
    text = '''Head Loss Calculator originally developed by Jason Nugent\nand ReefCentral, with pump curve information courtesy\nof Shane Graber and Reefs.org.\n\nCoverted to executable by Shane Graber and released under\nthe GNU General Public License. '''
    tkMessageBox.showinfo("About this Program", text)   

def config_problems(error):
    text = """Your %s configuration file is missing! """ % error
    tkMessageBox.showwarning("Problems / Errors", text)
    exit()

def entry_error():
    text = """There is an error in one of your data fields.\nPlease fix it and retry. """
    tkMessageBox.showerror('Data Entry Error', text)

# GRAPHICAL USER INTERFACE 

def make_window():
    """ create our gui """
    
    # our master window
    global win
    
    # our input fields
    global selected_pump_field, selected_diameter_field, height_field, length_field, elbows90_field
    global elbows45_field, gate_valves_field, ball_valves_field, unions_field, check_valves_field
    global exits_field, entrances_field
        
    # our output fields
    global ft_loss_field, psi_field, gph_field, velocity_field
        
    # functions called when pressing 'Solve' and 'Reset' buttons
    global solution, reset_fields

    # initiate the gui
    win = Tk()
    win.title('Pump Head Loss Calculator')
    win.geometry('350x520')
    
    # CREATE THE TOP LEVEL MENUBAR 
    
    # create a toplevel menu
    menubar = Menu(win)
    win.config(menu=menubar)
    
    # create file menu
    filemenu = Menu(menubar, tearoff=0, bg='#D4D0C8')
    menubar.add_cascade(label='File', menu=filemenu)
    filemenu.add_command(label='Save', command=save_file)
    filemenu.add_command(label='Quit', command=win.quit)

    # create help menu
    helpmenu = Menu(menubar, tearoff=0, bg='#D4D0C8')
    menubar.add_cascade(label='Help', menu=helpmenu)
    helpmenu.add_command(label='Usage', command=usage)
    helpmenu.add_command(label='About', command=about)

    # THE DATA ENTRY FIELDS 
    
    # add directions to window
    f1 = Frame(win)  
    f1.pack(side=TOP, pady=10)
    Label(f1, text="""Enter your plumbing configuration in the spaces provided."""
          ).grid(row=0, column=0)
    
    # add data entry fields to window
    # get pumps and pipe diameter selections
    fd = Frame(win)
    fd.pack(fill=BOTH, expand=1, padx=20)
    
    # pump dropdown menu
    allpumps = pumps_list()
    selected_pump_field = StringVar()
    pump_field = ComboBox(fd, 
                       label='Select Your Pump  ', 
                       editable=True,
                       variable=selected_pump_field,
                       options='listbox.height 6')
    pump_field.pack(fill=BOTH, expand=1, padx=10)
    for pump in allpumps:
        pump_field.insert(END, pump)
    # set initial value of dropdown box
    pump_field.set_silent(allpumps[0])
    
    # pipe diameter dropdown menu
    dias=[]
    pipes = pipesoup.findAll('pipe')
    for pipe in pipes:
        dias.append( float(pipe.string) )
    selected_diameter_field = StringVar()
    pipe_diameter_field = ComboBox(fd, 
                      label='Pipe Diameter (inches)  ', 
                      editable=True,
                      variable=selected_diameter_field,
                      options='listbox.height 4')
    pipe_diameter_field.pack(fill=BOTH, expand=1, padx=10)
    for dia in dias:
        pipe_diameter_field.insert(END, dia)
    # set initial value of dropdown box
    pipe_diameter_field.set_silent(dias[0])        
        
    # get rest of data
    f2 = Frame(win)  
    f2.pack(padx=10, pady=15)
    
    Label(f2, text='Vertical Height (feet)').grid(row=2, column=0, stick=W)
    height_field = StringVar()
    height = Control(f2, 
                     variable=height_field,
                     integer=False,
                     min=0,
                     max=50,
                     step=1)
    height.grid(row=2, column=1, pady=2, ipadx=10, sticky=E)
    
    Label(f2, text='Horizontal Length (feet)').grid(row=3, column=0, stick=W)
    length_field = StringVar()
    length = Control(f2, 
                     variable=length_field,
                     integer=False,
                     min=0,
                     max=50,
                     step=1)
    length.grid(row=3, column=1, pady=2, ipadx=10, sticky=E)

    Label(f2, text='Number of 90 Degree Elbows').grid(row=4, column=0, stick=W)
    elbows90_field = StringVar()
    elbows90 = Control(f2, 
                       variable=elbows90_field,
                       integer=True,
                       min=0,
                       max=20,
                       step=1)
    elbows90.grid(row=4, column=1, pady=2, ipadx=10, sticky=E)
    
    Label(f2, text='Number of 45 Degree Elbows').grid(row=5, column=0, stick=W)
    elbows45_field = StringVar()
    elbows45 = Control(f2, 
                       variable=elbows45_field,
                       integer=True,
                       min=0,
                       max=20,
                       step=1)
    elbows45.grid(row=5, column=1, pady=2, ipadx=10, sticky=E)
    
    Label(f2, text='Number of Gate Valves').grid(row=6, column=0, stick=W)
    gate_valves_field = StringVar()
    gv = Control(f2, 
                 variable=gate_valves_field,
                 integer=True,
                 min=0,
                 max=20,
                 step=1)
    gv.grid(row=6, column=1, pady=2, ipadx=10, sticky=E)
    
    Label(f2, text='Number of Ball Valves').grid(row=7, column=0, stick=W)
    ball_valves_field = StringVar()
    bv = Control(f2, 
                 variable=ball_valves_field,
                 integer=True,
                 min=0,
                 max=20,
                 step=1)
    bv.grid(row=7, column=1, pady=2, ipadx=10, sticky=E)
    
    Label(f2, text='Number of Union Couplings').grid(row=8, column=0, stick=W)
    unions_field = StringVar()
    uv = Control(f2, 
                 variable=unions_field,
                 integer=True,
                 min=0,
                 max=20,
                 step=1)
    uv.grid(row=8, column=1, pady=2, ipadx=10, sticky=E)
    
    Label(f2, text='Number of Swing Check Valves').grid(row=9, column=0, stick=W)
    check_valves_field = StringVar()
    cv = Control(f2, 
                 variable=check_valves_field,
                 integer=True,
                 min=0,
                 max=20,
                 step=1)
    cv.grid(row=9, column=1, pady=2, ipadx=10, sticky=E)

    Label(f2, text='Number of Pipe Exits').grid(row=10, column=0, sticky=W)
    exits_field = StringVar()
    exits = Control(f2, 
                    variable=exits_field,
                    integer=True,
                    min=1,
                    max=20,
                    step=1)
    exits.grid(row=10, column=1, pady=2, ipadx=10, sticky=E)

    Label(f2, text='Number of Pipe Entrances').grid(row=11, column=0, sticky=W)
    entrances_field = StringVar()
    entr = Control(f2, 
                   variable=entrances_field,
                   integer=True,
                   min=1,
                   max=20,
                   step=1)
    entr.grid(row=11, column=1, pady=2, ipadx=10, sticky=E)

    # SOLVE AND RESET BUTTONS 
    
    solve = Button(f2, text='Solve', padx=20, pady=5)
    solve.configure(command=solution)
    solve.grid(row=12, column=1, pady=15)
    
    reset = Button(f2, text='Reset', padx=20, pady=5)
    reset.configure(command=reset_fields)
    reset.grid(row=12, column=0, pady=15)
       
    # RESULTS FIELDS - Here's where our calculated results go 
    
    Label(f2, text='Head Pressure (ft loss)').grid(row=13, column=0, sticky=W)
    ft_loss_field = StringVar()
    head = Entry(f2, textvariable=ft_loss_field)
    head.grid(row=13, column=1)

    Label(f2, text='Head Pressure (psi)').grid(row=14, column=0, sticky=W)
    psi_field = StringVar()
    psi = Entry(f2, textvariable=psi_field)
    psi.grid(row=14, column=1, sticky=W)

    Label(f2, text='Total Flow Rate (gph)').grid(row=15, column=0, sticky=W)
    gph_field = StringVar()
    gph = Entry(f2, textvariable=gph_field)
    gph.grid(row=15, column=1, sticky=W)

    Label(f2, text='Exit Velocity Per Exit (ft/sec)').grid(row=16, column=0, sticky=W)
    velocity_field = StringVar()
    v = Entry(f2, textvariable=velocity_field)
    v.grid(row=16, column=1, sticky=W)
    
    # set all of our fields to an initial value
    reset_fields()
    
    return win

def main():
    ''' The start of our program.  Read xml files and fire up the gui. '''
    
    # our xml data
    global pumpsoup, frictionsoup, pipesoup

    # read our xml datafiles
    pumpsoup = open_file('pumps.xml')
    frictionsoup = open_file('frictions.xml')
    pipesoup = open_file('pipes.xml')

    # initiate our gui
    win = make_window()
    win.mainloop()

if __name__ == '__main__':
    main()